iT邦幫忙

2024 iThome 鐵人賽

DAY 29
0
Software Development

一起看無間道學EdgeDB系列 第 29

[Day29] - 學習如何使用Axum搭配EdgeDB建立Rust weather app

  • 分享至 

  • xImage
  •  

EdgeDB的官網上截至目前為止,仍然沒有使用Rust搭配EdgeDB的範例。所幸在其GitHub的PR區中,找到一篇由David MacLeod所寫但卻未合併的範例。這個範例使用Axum框架,目的是希望建立一個透過Open-Meteo來查詢天氣的app。

今天的內容讓我們一起跟隨David的腳步,來學習在Rust中使用EdgeDB。不過請留意,此範例有幾個需要修改的地方:

  • 將原先的select_city()命名為select_cities(),並將WeatherApp .get_cities()中呼叫select_city()的部份改為呼叫select_cities()
  • 新增一個select_city()(呼叫select_cities()並加入assert_single())。
  • 修改insert_conditions()(加入assert_single())。

Schema

Schema中定義三個scalar type及兩個object type

scalar type

三個scalar typeextendingfloat64而來,分別代表經度、緯度及溫度,並各自設置合理的constraint。

scalar type Latitude extending float64 {
  constraint max_value(90.0);
  constraint min_value(-90.0);
}
scalar type Longitude extending float64 {
  constraint max_value(180.0);
  constraint min_value(-180.0);
}
scalar type Temperature extending float64 {
  constraint max_value(70.0);
  constraint min_value(-100.0);
}

object type

Conditions object type

  • 使用temperaturetime兩個property分別記錄溫度及時間。
  • city link設定delete policyon target delete delete source,即當此City object刪除時,與其連接的所有Conditions type也會刪除,即所謂的cascading delete。
  • 其針對(.time, .city)使用exclusive的constraint來確保同個城市在同一時間,不會有兩個不同溫度。

City object type

  • namelatitudelongitude三個property分別城市名、經度及緯度。
  • conditions multi link連接所有該City objectConditions object type
  • key是一computed property,串連三個property之值(str型別)。
  • 針對key使用exclusive()的constraint來確保城市名、經度及緯度的組合是唯一的。
type Conditions {
    required city: City {
        on target delete delete source;
    }
    required temperature: Temperature;
    required time: str;
    constraint exclusive on ((.time, .city));
}


type City {
  required name: str;
  required latitude: Latitude;
  required longitude: Longitude;
  multi conditions := (select .<city[is Conditions] order by .time);
  key := .name ++ <str><int64>.latitude ++ <str><int64>.longitude;
  constraint exclusive on (.key);
}

Query

select_cities()

select_cities()目的為使用字串來組成query,用來選取多個城市,並接受一個filter參數供使用者輸入篩選條件。

fn select_cities(filter: &str) -> String {
    let mut output = "select City { 
        name, 
        latitude, 
        longitude,
        conditions: { temperature, time }
    } "
    .to_string();
    output.push_str(filter);
    output
}

select_city()

select_city()目的為使用字串來組成query,用來選取單一城市,並接受一個filter參數供使用者輸入篩選條件。由於是選取單一城市,所以我們必須在呼叫select_cities()後再加上assert_single()

fn select_city(filter: &str) -> String {
    let output = select_cities(filter);
    format!("select assert_single(({output}));")
}

insert_city()

insert_city()目的為使用字串來組成query,用來生成一個City object

fn insert_city() -> &'static str {
    "insert City {
        name := <str>$0,
        latitude := <float64>$1,
        longitude := <float64>$2,
    };"
}

insert_conditions()

insert_conditions()目的為使用字串來組成query,用來生成一個Conditions object。由於其中的city linksingle,所以必須記得使用assert_single()

fn insert_conditions() -> &'static str {
    "insert Conditions {
        city := assert_single((select City filter .name = <str>$0)),
        temperature := <float64>$1,
        time := <str>$2 
    }"
}

delete_city()

delete_city()目的為使用字串來組成query,用來刪除一個City object

fn delete_city() -> &'static str {
    "delete City filter .name = <str>$0"
}

select_city_names()

select_city_names()目的為使用字串來組成query,用來選取全部City objectname property形成的EdgeDBSet,並以字母排序。

fn select_city_names() -> &'static str {
    "select City.name order by City.name"
}

Axum app

以下程式碼包括三項工作:

  • 使用WeatherAppinit()來初始化,並使用tokio提供的spawn()搭配WeatherApprun()來開啟一個執行緒。
  • 建立app,並使用Router.route()添加相關路由函數及使用Router.with_state()添加所需資源。
  • 使用axum::serve()建立服務,讓使用者可使用port 3000與此app互動。
use axum::{
    extract::{Path, State},
    routing::get,
    Router,
};
use edgedb_errors::ConstraintViolationError;
use edgedb_protocol::value::Value;
use edgedb_tokio::{create_client, Client, Queryable};
use serde::Deserialize;
use std::time::Duration;
use tokio::{net::TcpListener, time::sleep};


#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
    let client = create_client().await?;
    let weather_app = WeatherApp { db: client.clone() };
    weather_app.init().await;
    tokio::task::spawn(async move {
        weather_app.run().await;
    });
    let app = Router::new()
        .route("/", get(menu))
        .route("/conditions/:name", get(get_conditions))
        .route("/add_city/:name/:latitude/:longitude", get(add_city))
        .route("/remove_city/:name", get(remove_city))
        .route("/city_names", get(city_names))
        .with_state(client)
        .into_make_service();
    let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
    Ok(())
}

值得注意的是,這邊關於新增與移除城市的路由設定,應該使用post(add_city)post(remove_city)(或是delete(remove_city))較佳,我相信David這樣寫是為了我們能夠快速在瀏覽器與其互動。

Axum函數

/對應menu()

menu()功能為將所有路由資訊呈現於首頁。

async fn menu() -> &'static str {
    "Routes:
            /conditions/<name>
            /add_city/<name>/<latitude>/<longitude>
            /remove_city/<name>
            /city_names"
}

/conditions/:name對應get_conditions()

get_conditions()功能為顯示該城市已記錄的天氣記錄,其內使用select_city()為query字串。

async fn get_conditions(
    Path(city_name): Path<String>,
    State(client): State<Client>,
) -> String {
    let query = select_city("filter .name = <str>$0");
    match client
        .query_required_single::<City, _>(&query, &(&city_name,))
        .await
    {
        Ok(city) => {
            let mut conditions = format!("Conditions for {city_name}:\n\n");
            for condition in city.conditions.unwrap_or_default() {
                let (date, hour) =
                    condition.time.split_once("T").unwrap_or_default();
                conditions.push_str(&format!("{date} {hour}\t"));
                conditions.push_str(&format!("{}\n", condition.temperature));
            }
            conditions
        }
        Err(e) => format!("Couldn't find {city_name}: {e}"),
    }
}

/add_city/:name/:latitude/:longitude對應add_city()

add_city()功能為新增城市至EdgeDB中,其內使用insert_city()insert_conditions()為query字串。

async fn add_city(
    State(client): State<Client>,
    Path((name, lat, long)): Path<(String, f64, f64)>,
) -> String {
    // First make sure that Open-Meteo is okay with it
    let (temperature, time) = match weather_for(lat, long).await {
        Ok(c) => (c.temperature, c.time),
        Err(e) => {
            return format!("Couldn't get weather info: {e}");
        }
    };
    // Then insert the City
    if let Err(e) = client.execute(insert_city(), &(&name, lat, long)).await {
        return e.to_string();
    }
    // And finally the Conditions
    if let Err(e) = client
        .execute(insert_conditions(), &(&name, temperature, time))
        .await
    {
        return format!(
            "Inserted City {name} but couldn't insert conditions: {e}"
        );
    }
    format!("Inserted city {name}!")
}

/remove_city/:name對應remove_city()

remove_city()功能為將城市自EdgeDB中移除,其內使用delete_city()為query字串。

async fn remove_city(
    Path(name): Path<String>,
    State(client): State<Client>,
) -> String {
    match client.query::<Value, _>(delete_city(), &(&name,)).await {
        Ok(v) if v.is_empty() => format!("No city {name} found to remove!"),
        Ok(_) => format!("City {name} removed!"),
        Err(e) => e.to_string(),
    }
}

/city_names對應city_names()

city_names()功能為取得當前已記錄的所有城市名,其內使用select_city_names()為query字串。

async fn city_names(State(client): State<Client>) -> String {
    match client.query::<String, _>(select_city_names(), &()).await {
        Ok(cities) => format!("{cities:#?}"),
        Err(e) => e.to_string(),
    }
}

Struct

此處定義所有需要用到的struct ,比較值得一提的是WeatherAppinit()run()

init()中會於app第一次執行時,加入六個城市至EdgeDB。之後當app再次被啟動時,由於City object有針對城市名、經度及緯度進行exclusive()的constraint,這些新增將不會成。此時我們使用Rust的match功能補抓到此錯誤,並印出其是否為ConstraintViolationError

run()我們建立一個無限迴圈,每隔六十秒呼叫update_conditions()來加入新的天氣記錄。

#[derive(Queryable, Debug)]
struct City {
    name: String,
    latitude: f64,
    longitude: f64,
    conditions: Option<Vec<CurrentWeather>>,
}

#[derive(Deserialize, Queryable, Debug)]
struct WeatherResult {
    current_weather: CurrentWeather,
}

#[derive(Deserialize, Queryable, Debug)]
struct CurrentWeather {
    temperature: f64,
    time: String,
}

async fn weather_for(
    latitude: f64,
    longitude: f64,
) -> Result<CurrentWeather, anyhow::Error> {
    let url = format!(
        "https://api.open-meteo.com/v1/forecast?\
        latitude={latitude}&longitude={longitude}\
        &current_weather=true&timezone=CET"
    );
    let res = reqwest::get(url).await?.text().await?;
    let weather_result: WeatherResult = serde_json::from_str(&res)?;
    Ok(weather_result.current_weather)
}

struct WeatherApp {
    db: Client,
}

impl WeatherApp {
    async fn init(&self) {
        let city_data = [
            ("Andorra la Vella", 42.3, 1.3),
            ("El Serrat", 42.37, 1.33),
            ("Encamp", 42.32, 1.35),
            ("Les Escaldes", 42.3, 1.32),
            ("Sant Julià de Lòria", 42.28, 1.29),
            ("Soldeu", 42.34, 1.4),
        ];
        let query = insert_city();
        for (name, lat, long) in city_data {
            match self.db.execute(query, &(name, lat, long)).await {
                Ok(_) => println!("City {name} inserted!"),
                Err(e) => {
                    if e.is::<ConstraintViolationError>() {
                        println!("City {name} already in db");
                    } else {
                        println!("Unexpected error: {e:?}");
                    }
                }
            }
        }
    }
    
    async fn get_cities(&self) -> Result<Vec<City>, anyhow::Error> {
        Ok(self.db.query::<City, _>(&select_cities(""), &()).await?)
    }
    
    async fn update_conditions(&self) -> Result<(), anyhow::Error> {
        for City {
            name,
            latitude,
            longitude,
            ..
        } in self.get_cities().await?
        {
            let CurrentWeather { temperature, time } =
                weather_for(latitude, longitude).await?;
            match self
                .db
                .execute(insert_conditions(), &(&name, temperature, time))
                .await
            {
                Ok(()) => println!("Inserted new conditions for {}", name),
                Err(e) => {
                    if !e.is::<ConstraintViolationError>() {
                        println!("Unexpected error: {e}");
                    }
                }
            }
        }
        Ok(())
    }
    
    async fn run(&self) {
        sleep(Duration::from_millis(100)).await;
        loop {
            println!("Looping...");
            if let Err(e) = self.update_conditions().await {
                println!("Loop isn't working: {e}")
            }
            sleep(Duration::from_secs(60)).await;
        }
    }

執行app

於命令列中執行下列指令:

cargo run

應該可以看到以下輸出:

City Andorra la Vella inserted!
City El Serrat inserted!
City Encamp inserted!
City Les Escaldes inserted!
City Sant Julià de Lòria inserted!
City Soldeu inserted!
Looping...
Inserted new conditions for Andorra la Vella
Inserted new conditions for El Serrat
Inserted new conditions for Encamp
Inserted new conditions for Les Escaldes
Inserted new conditions for Sant Julià de Lòria
Inserted new conditions for Soldeu
Looping...

接著打開瀏覽器前往http://127.0.0.1:3000/,就可以見到預設的菜單:

Routes:
            /conditions/<name>
            /add_city/<name>/<latitude>/<longitude>
            /remove_city/<name>
            /city_names

以下我們依序測試其各種功能:

/city_names

於瀏覽器中輸入http://127.0.0.1:3000/city_names

[
    "Andorra la Vella",
    "El Serrat",
    "Encamp",
    "Les Escaldes",
    "Sant Julià de Lòria",
    "Soldeu",
]

可以確認預設的六個城市都已成功加入。

/add_city/<name>/<latitude>/<longitude>

試著將Taipei加進EdgeDB,於瀏覽器中輸入http://127.0.0.1:3000/add_city/Taipei/25.02/121.33

Inserted city Taipei!

可以看見成功加入Taipei的訊息。

如果於瀏覽器中輸入http://127.0.0.1:3000/city_names

[
    "Andorra la Vella",
    "El Serrat",
    "Encamp",
    "Les Escaldes",
    "Sant Julià de Lòria",
    "Soldeu",
    "Taipei",
]

可以確認Taipei已經存入EdgeDB中了。

/conditions/<name>

於瀏覽器中輸入http://127.0.0.1:3000/conditions/Taipei

Conditions for Taipei:

2024-10-09 17:30	21.9
2024-10-09 17:45	21.9
2024-10-09 18:00	21.9
2024-10-09 18:15	21.9
2024-10-09 18:30	21.9
2024-10-09 18:45	21.9

可以看見Taipei當前已記錄的天氣資訊。

/remove_city/<name>

於瀏覽器中輸入http://127.0.0.1:3000/remove_city/Taipei

City Taipei removed!

可以看見Taipei被成功移除的訊息。

如果於瀏覽器中輸入http://127.0.0.1:3000/city_names

[
    "Andorra la Vella",
    "El Serrat",
    "Encamp",
    "Les Escaldes",
    "Sant Julià de Lòria",
    "Soldeu",
]

可以確認Taipei的確從EdgeDB中移除了。

備註

註1:如果在Windows環境下編譯這個範例,可能會出現找不到aws-lc-sys的問題,需要自行下載後,再設定至環境變數的Path中。

註2:由於目前EdgeDB似乎還沒有Rust版本的query builder,所以David折衷地將query寫為數個函數。

Code

本日所有程式碼可參考edgedb-axum-weather-app repo。


上一篇
[Day28] - 使用SVCS、FastHTML搭配EdgeDB建立Python todo app(2)
下一篇
[Day30] - 結語
系列文
一起看無間道學EdgeDB30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言